Java NIO - Netty 支持 mqtt消息、SSL证书双向认证、token、以及服务端主动下发消息的测试案例

搭建 netty 服务器基础版

引入依赖

1
2
3
4
5
6
7
8
9
10
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>


最简单的业务处理器

我们先写一个最简单的工人,它只干一件事:收到客户端发来的字符串,打印出来,并原样送回去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.owlias.mqtt.server.handler;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class SimpleEchoHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
// 每当消息发送时触发
log.info("收到客户端消息:{}", msg);
ctx.writeAndFlush("服务端已收到:" + msg + "\n");
}

@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.info("客户端断开连接:{}", ctx.channel().remoteAddress() );
super.channelInactive(ctx);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
log.error("发生异常", cause);
ctx.close(); // 关闭这条通道
}
}


配置并启动 NettyServer

组装配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Slf4j
@Component
public class NettyServer {

// 1. 定义两个线程组(大包工头和小工)
private final EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 1个线程绰绰有余
private final EventLoopGroup workerGroup = new NioEventLoopGroup(); // 默认是 CPU 核心数 * 2

@PostConstruct
public void start() throws InterruptedException {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 往流水线上添加程序
// 字节流进站,把二进制转换成string
ch.pipeline().addLast(new StringDecoder());
// String 出站:把 String 变成二进制发给客户端
ch.pipeline().addLast(new StringEncoder());
// 最后加入我们自己写的业务工人
ch.pipeline().addLast(new SimpleEchoHandler());
}
});

int port = 8888;
ChannelFuture future = bootstrap.bind(port).sync();
log.info(">>>> 我的第一个 Netty 服务器启动成功,监听端口: {} <<<<", port);
}

@PreDestroy
public void stop() {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
log.info("Netty 服务器优雅关闭");
}
}

启动类:

1
2
3
4
5
6
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class);
}
}

启动你的 Spring Boot 应用程序。这时候,你不需要写前端客户端,直接打开你电脑的终端利用操作系统自带的 telnet 或 nc(Netcat)命令连接你的服务器:

1
2
nc localhost 8888
# 或者 telnet localhost 8888


让系统支持 MQTT

在上面,客户端发什么字符串,服务器就接什么字符串。但在实际的系统中,客户端和服务端通信必须是一套严密的、结构化的二进制协议。这里我们使用 MQTT 协议。

当客户端连上服务器时,它发出的第一个请求不能再是普通的文本,而必须是一个 MQTT CONNECT(请求连接)报文。里面包含了它的设备 ID、用户名和密码。服务器收到后,必须回一个 MQTT CONNACK(确认连接)报文,两边才算真正“握手成功”。


编写 MQTT 业务处理器

Netty 官方已经帮我们把 MQTT 的编解码器写好了(就在 netty-all 依赖里)。我们只需要把上面流水线上的 “String工人” 换成 “MQTT 工人” 即可。它的职责是专门拦截客户端发来的 MQTT CONNECT 消息,把里面的 ClientId(资产ID)、Username(用户)榨取出来,然后给客户端回一个 “允许连接” 的答复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.owlias.mqtt.server.handler;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.mqtt.*;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MqttConnectHandler extends SimpleChannelInboundHandler<MqttMessage> {

@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
// 1. 检查这个消息是不是客户端发来的 CONNECT(连接请求)
if (msg.fixedHeader().messageType() == MqttMessageType.CONNECT) {
// 2. 强转成标准的 MQTT 连接消息对象
MqttConnectMessage connectMessage = (MqttConnectMessage) msg;
// 3. 提取客户端传过来的 “暗号”
String clientId = connectMessage.payload().clientIdentifier();
String username = connectMessage.payload().userName();
// TODO 鉴权
log.info(">>>> [MQTT网关] 收到客户端连接请求! ClientId(资产ID): {}, Username: {}", clientId, username);
// 4. 给客户端特制一个 “准许入网” 的回复报文 (CONNACK)
MqttConnAckMessage connAckMessage = getMqttConnAckMessage();

// 5. 发送给客户端
ctx.writeAndFlush(connAckMessage);
log.info("<<<< [MQTT网关] 已向客户端发送握手成功响应 (CONNACK)");

} else {
// 如果不是 CONNECT 消息(比如向topic发消息),先打印出来,后续我们再加别的 Handler 拓展
log.info("收到非CONNECT类型的MQTT消息: {}", msg.fixedHeader().messageType());
}
}

private static MqttConnAckMessage getMqttConnAckMessage() {
MqttFixedHeader fixedHeader = new MqttFixedHeader(
MqttMessageType.CONNACK, // 消息类型:连接确认
false,
MqttQoS.AT_MOST_ONCE,
false,
0
);
MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(
MqttConnectReturnCode.CONNECTION_ACCEPTED, // 状态码:接受连接(成功)
false
);
// 组装成最终的 MQTT 消息
MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader);
return connAckMessage;
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("通道发生异常", cause);
ctx.close();
}
}


更新流水线

回到服务器配置类,把之前的 StringDecoder / StringEncoder 换成标准的 MqttDecoder 和 MqttEncoder,并换上我们刚写好的工厂工人:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void initChannel(SocketChannel ch) {
// 【核心改动】把原先的 String 编解码器换成 MQTT 编解码器

// 入站方向:把网络上的二进制字节流,解码成 Netty 的 MqttMessage 对象
ch.pipeline().addLast(new MqttDecoder());

// 出站方向:把服务器写的 MqttMessage 对象,编码成二进制字节流发回给网络
ch.pipeline().addLast(MqttEncoder.INSTANCE);

// 换上我们的 MQTT 业务处理器
ch.pipeline().addLast(new MqttConnectHandler());
}

因为 MQTT 协议传输的是复杂的二进制字节块,普通的终端命令 nc 或 telnet 没办法模拟发送标准的 MQTT 报文了。为了验证它,可以去官网下载一个免费的 MQTTX 客户端桌面软件(类似 Postman,专门调 MQTT 的)。

1
wget from https://mqttx.app/downloads


连接池和心跳检测支持

现在,MQTTX 客户端显示“已连接”,但对服务器来说,握手完方法就结束了,服务器在内存里并没有把这个连接“存”下来。如果服务端想要主动给这个客户端发一条 “查杀病毒” 的指令,服务器根本找不到这个客户端的 “电话号码(Channel)”。所以,我们顺理成章地需要建立“生死簿”,管好海量连接。我们要完成的两个目标:

  • 只要有客户端连进来,就必须把它登记在册,方便以后随时“反向推送”消息。
  • 客户端断网、拔网线、或者装死,网关必须能在指定时间内(比如 10 秒没动静)自动断开死连接,释放服务器内存。


简易连接管理器

ServerConnectionManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Slf4j
@Component
public class ServerConnectionManager {

// 在内存中维护所有活着的客户端:Key 是客户端设备 ID (clientId),Value 是通信管道 (Channel)
private final ConcurrentHashMap<String, Channel> connectionPool = new ConcurrentHashMap<>();

// 登记注册连接
public void addConnection(String clientId, Channel channel) {
connectionPool.put(clientId, channel);
log.info("【生死簿】登记新连接 -> 设备: {}, 当前在线总数: {}", clientId, connectionPool.size());
}

// 注销清理连接
public void removeConnection(String clientId) {
if (clientId != null && connectionPool.containsKey(clientId)) {
connectionPool.remove(clientId);
log.warn("【生死簿】注销老连接 -> 设备: {}, 当前在线总数: {}", clientId, connectionPool.size());
}
}

// 根据设备 ID 获取连接(之后反向推送指令就靠它!)
public Channel getConnection(String clientId) {
return connectionPool.get(clientId);
}
}


将连接成功登记在册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Slf4j
@Component
@ChannelHandler.Sharable // 让这个 Handler 变成单例,可以被多通道安全共享
public class MqttConnectHandler extends SimpleChannelInboundHandler<MqttMessage> {

@Resource
private ServerConnectionManager connectionManager;
// 定义一个 Netty 属性标签,用来把 clientId 缝合到 channel 上
public static final AttributeKey<String> ATTR_CLIENT_ID = AttributeKey.valueOf("clientId");

@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
if (msg.fixedHeader().messageType() == MqttMessageType.CONNECT) {
MqttConnectMessage connectMessage = (MqttConnectMessage) msg;
String clientId = connectMessage.payload().clientIdentifier();
String username = connectMessage.payload().userName();
log.info(">>>> [MQTT网关] 收到客户端连接请求! ClientId(资产ID): {}, Username: {}", clientId, username);

// 给 Channel 贴上标签,记住它属于谁
ctx.channel().attr(ATTR_CLIENT_ID).set(clientId);
// 塞进生死簿管理器中
connectionManager.addConnection(clientId, ctx.channel());

// 4. 给客户端特制一个 “准许入网” 的回复报文 (CONNACK)
MqttConnAckMessage connAckMessage = getMqttConnAckMessage();

// 5. 发送给客户端
ctx.writeAndFlush(connAckMessage);
log.info("<<<< [MQTT网关] 已向客户端发送握手成功响应 (CONNACK)");

} else {
log.info("收到非CONNECT类型的MQTT消息: {},顺延给下一个Handler处理", msg.fixedHeader().messageType());

// 1. 既然本工人不处理,就必须显式调用 fireChannelRead 把它送往流水线的下一站
// 2. 并且由于 SimpleChannelInboundHandler 会在当前方法退出时自动执行内存释放(Release),
// 我们必须先调用 retain() 让引用计数 +1,防止下游工人拿到一个被销毁的断头消息。
ReferenceCountUtil.retain(msg);
ctx.fireChannelRead(msg);
}
}

@Override // 【关键】如果客户端异常断开或主动掐断,触发此方法
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
String clientId = ctx.channel().attr(ATTR_CLIENT_ID).get(); // 从标签里取出这个人是谁,然后从生死簿里剔除
connectionManager.removeConnection(clientId);
super.channelInactive(ctx);
}

private static MqttConnAckMessage getMqttConnAckMessage() {
MqttFixedHeader fixedHeader = new MqttFixedHeader(
MqttMessageType.CONNACK, // 消息类型:连接确认
false,
MqttQoS.AT_MOST_ONCE,
false,
0
);
MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(
MqttConnectReturnCode.CONNECTION_ACCEPTED, // 状态码:接受连接(成功)
false
);
// 组装成最终的 MQTT 消息
MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader);
return connAckMessage;
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("通道发生异常", cause);
ctx.close();
}
}


加入心跳检测支持

海量长连接最怕死连接(比如客户端断网了,但服务器没收到断开通知,连接一直占着堆内存)。我们必须引入心跳监控。原理是服务器盯着这个通道,如果比如 10 秒钟没读到任何客户端的数据,就触发超时,强制掐断。如果客户端想活着,就必须每隔几秒发一个 PINGREQ(心跳包),服务器收到心跳包后,可以给客户端续命,并回一个 PINGRESP 给客户端。

我们新建一个 HeartbeatHandler,用来抓取超时事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {

@Override // 专门用来捕捉 Netty 触发的各种“内省事件”(比如空闲超时)
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
// 如果触发了 READER_IDLE(读空闲,说明太久没收到客户端发消息了)
if (event.state() == IdleState.READER_IDLE) {
log.warn("【心跳卫士】该连接已超过10秒无任何数据交互,确认为死连接,强制关闭!Channel: {}", ctx.channel().remoteAddress());
ctx.channel().close(); // 物理关闭连接
}
} else {
super.userEventTriggered(ctx, evt);
}
}
}

再新建一个心跳续命工人 MqttPingReqHandler,专门拦截客户端的 PINGREQ 心跳包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Slf4j
@Component
@ChannelHandler.Sharable
public class MqttPingReqHandler extends SimpleChannelInboundHandler<MqttMessage> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
// 如果是客户端发来的 PING 请求
if (msg.fixedHeader().messageType() == MqttMessageType.PINGREQ) {
log.info("【心跳卫士】收到客户端 PING 包,连接保持健康状态.");

// 组装一个 PINGRESP 回复包送给客户端
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PINGRESP, false, MqttQoS.AT_MOST_ONCE, false, 0);
ctx.writeAndFlush(new MqttMessage(fixedHeader));
} else {
ReferenceCountUtil.retain(msg); // 必须加上这行保命!
// 传给流水线的下一个工人
ctx.fireChannelRead(msg);
}
}
}


把新关卡组装进流水线

注意:由于 MqttConnectHandler 和 MqttPingReqHandler 现在加上了 @Component 并注入了依赖,请把 MyFirstNettyServer 里原本 new 这两个 Handler 的地方,改成用 @Resource 注入成员变量使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Resource
private MqttConnectHandler mqttConnectHandler;
@Resource
private MqttPingReqHandler mqttPingReqHandler;

// ...

@Override // 每当有一个新的客户端连接(Channel)建立时,initChannel 就会被调用一次
protected void initChannel(SocketChannel ch) {
// 1. 挂载一个 Netty 官方的 “秒表计时器”:参数(读超时秒数, 写超时秒数, 所有超时秒数)
// 为了测试效果明显,我们设为 10 秒。如果 10 秒客户端没有任何消息进站,就会向下游发送超时信号
ch.pipeline().addLast(new IdleStateHandler(10, 0, 0));

// 2. 编解码器不变。注意原则是入站控状态(按通道隔离),出站控性能(全局单例共享)
ch.pipeline().addLast(new MqttDecoder()); // in 入站的解码器是有状态的(处理TCP的粘包和半包)
ch.pipeline().addLast(MqttEncoder.INSTANCE); // out 出站的编码器是无状态的(标着共享注解!)

// 3. 业务处理器排队
ch.pipeline().addLast(mqttConnectHandler); // in 握手并登记生死簿
ch.pipeline().addLast(mqttPingReqHandler); // in 拦截 PING 包续命
ch.pipeline().addLast(new HeartbeatHandler()); // in 专门蹲守计时器超时,负责斩断物理连接
}


实现动态心跳机制

在真实的物联网场景中,不能在 NettyServer 里把心跳时间硬编码死。因为有的设备为了省电可能 60 秒发一次心跳,有的设备可能 10 秒发一次。为了充分利用 MQTT 协议的特性,在客户端发起 CONNECT 握手时,动态读取它自带的 Keep Alive 字段,然后动态替换 Netty 流水线里的超时检测器。


修改 NettyServer

给 IdleStateHandler 取一个固定的名字(比如 “idleStateHandler”),以便后续动态替换它。同时将默认的读超时改回一个较大的安全兜底值(如 120 秒):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 找到你的 NettyServer.java 中的 initChannel 方法
@Override
protected void initChannel(SocketChannel ch) {
// 【修改点】给心跳处理器显式命名 "idleStateHandler",默认给 120 秒兜底
ch.pipeline().addLast("idleStateHandler", new IdleStateHandler(120, 0, 0));

ch.pipeline().addLast(new MqttDecoder());
ch.pipeline().addLast(MqttEncoder.INSTANCE);

ch.pipeline().addLast(mqttConnectHandler);
ch.pipeline().addLast(mqttPingReqHandler);
ch.pipeline().addLast(new HeartbeatHandler());
}


升级 MqttConnectHandler

在收到客户端 CONNECT 报文时,提取其内置的 keepAliveTimeSeconds(客户端期望的心跳频率),然后利用 Netty 的 pipeline().replace() 动态冲刷掉原有的 120 秒兜底器,替换为 1.5 x KeepAlive 的定制计时器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
if (msg.fixedHeader().messageType() == MqttMessageType.CONNECT) {
MqttConnectMessage connectMessage = (MqttConnectMessage) msg;
String clientId = connectMessage.payload().clientIdentifier();
String username = connectMessage.payload().userName();
log.info(">>>> [MQTT网关] 收到客户端连接请求! ClientId(资产ID): {}, Username: {}", clientId, username);

// ================== 【核心修改点:实现动态心跳】 ==================
// 1. 从 MQTT 协议的可变头部中获取客户端要求的 Keep Alive 时间(单位:秒)
int clientKeepAlive = connectMessage.variableHeader().keepAliveTimeSeconds();
if (clientKeepAlive > 0) {
// 2. 遵循 MQTT 协议规范,服务端判死阈值设为客户端心跳周期的 1.5 倍
int serverIdleTimeout = (int) (clientKeepAlive * 1.5);
// 3. 动态替换当前 Channel 流水线中名为 "idleStateHandler" 的旧处理器
ctx.pipeline().replace("idleStateHandler", "idleStateHandler",
new IdleStateHandler(serverIdleTimeout, 0, 0));
log.info(">>>> [MQTT网关] 设备 {} 携带 KeepAlive = {} 秒,服务端已动态调整读超时判死阈值为 {} 秒",
clientId, clientKeepAlive, serverIdleTimeout);
} else {
// 如果客户端传 0,意味着客户端不想让服务端主动踢它下线(通常不建议,可保留默认 120s 兜底)
log.info(">>>> [MQTT网关] 设备 {} 设置 KeepAlive 为 0 (永不超时),服务端保持默认 120 秒安全兜底", clientId);
}
// ==================================================================

// 给 Channel 贴上标签,记住它属于谁
ctx.channel().attr(ATTR_CLIENT_ID).set(clientId);
// 塞进生死簿管理器中
connectionManager.addConnection(clientId, ctx.channel());

// 4. 给客户端特制一个 “准许入网” 的回复报文 (CONNACK)
MqttConnAckMessage connAckMessage = getMqttConnAckMessage();

// 5. 发送给客户端
ctx.writeAndFlush(connAckMessage);
log.info("<<<< [MQTT网关] 已向客户端发送握手成功响应 (CONNACK)");
} else {
log.info("收到非CONNECT类型的MQTT消息: {},顺延给下一个Handler处理", msg.fixedHeader().messageType());
ReferenceCountUtil.retain(msg);
ctx.fireChannelRead(msg);
}
}


微调 HeartbeatHandler

因为心跳时间已经变成了动态自适应的(可能 15 秒、90 秒甚至根据设备而异),我们把原来的硬编码日志 “该连接已超过10秒…” 改为更通用的动态描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {

@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
// 如果触发了 READER_IDLE(读空闲,说明在 1.5 倍的防抖时间内,没收到客户端任何字节数据)
if (event.state() == IdleState.READER_IDLE) {
// 【修改点】将日志文案改为自适应描述
log.warn("【心跳卫士】该连接已超过预设的 1.5 倍心跳周期无任何数据交互,确认为死连接,强制关闭!Channel: {}",
ctx.channel().remoteAddress());
ctx.channel().close(); // 物理关闭连接
}
} else {
super.userEventTriggered(ctx, evt);
}
}
}


支持 SSL 双向认证

准备证书

第一步:创建私有 CA (根证书)。这是信任链的源头,我们要生成 CA 的私钥和根证书。

1
2
3
4
5
# 1. 生成 CA 私钥
openssl genrsa -out ca.key 4096

# 2. 生成 CA 根证书 (有效期 10 年)
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=Koohub-Root-CA"

第二步:生成服务端证书 (Server Certificate)。为了满足你的 CN=Koohub 需求,并解决浏览器/客户端校验问题,我们需要创建一个包含 subjectAltName 的服务端证书。

1
2
3
4
5
6
7
8
9
10
11
# 1. 生成服务端私钥
openssl genrsa -out server.key 2048

# 2. 生成服务端 CSR (证书签名请求)
openssl req -new -key server.key -out server.csr -subj "/CN=Koohub"

# 3. 使用 CA 签发服务端证书 (带 SAN 扩展以通过域名校验)
openssl x509 -req -days 365 -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -extensions v3_req -extfile <(printf "[v3_req]\nsubjectAltName=DNS:Koohub,IP:127.0.0.1")

# 4. 【重要】将私钥转换为 Java 兼容的 PKCS#8 格式
openssl pkcs8 -topk8 -inform PEM -outform PEM -in server.key -out server_pkcs8.key -nocrypt

第三步:生成客户端证书 (Client Certificate)。这是你发给张三或者其他设备使用的证书,服务端会用第一步生成的 ca.crt 来校验它。

1
2
3
4
5
6
7
8
9
10
11
# 1. 生成客户端私钥
openssl genrsa -out client.key 2048

# 2. 生成客户端 CSR
openssl req -new -key client.key -out client.csr -subj "/CN=zhangsan@koohub.com"

# 3. 使用 CA 签发客户端证书
openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt

# 4. (可选) 如果客户端需要 .p12 格式,执行此步
openssl pkcs12 -export -in client.crt -inkey client.key -out zhangsan@koohub.com.p12 -name "zhangsan"


编写 SSL 工厂类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import io.netty.handler.ssl.ClientAuth;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.SslHandler;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import java.io.InputStream;

@Slf4j
@Component
public class SslHandlerFactory {

private SslContext sslContext;

@PostConstruct
public void initSslContext() {
try {
// 1. 加载服务端的证书和私钥(证明我是真的服务器)
InputStream serverCert = new ClassPathResource("certs/server.crt").getInputStream();
InputStream serverKey = new ClassPathResource("certs/server_pkcs8.key").getInputStream();

// 2. 加载 CA 根证书(用来校验连上来的客户端是不是我们自己人)
InputStream rootCa = new ClassPathResource("certs/ca.crt").getInputStream();

// 3. 构建 Netty 的 SslContext
this.sslContext = SslContextBuilder.forServer(serverCert, serverKey)
.trustManager(rootCa) // 配置信任的 CA
.clientAuth(ClientAuth.REQUIRE) // REQUIRE 表示开启强制双向认证!
.build();

log.info(">>>> 双向 SSL (mTLS) 上下文初始化成功 <<<<");
} catch (Exception e) {
log.error("SSL 上下文初始化失败", e);
}
}

// 给流水线派发加密保安
public SslHandler createSslHandler(io.netty.buffer.ByteBufAllocator allocator) {
if (sslContext == null) {
throw new IllegalStateException("SSL Context 未正确初始化");
}
return sslContext.newHandler(allocator);
}
}


将认证插到流水线最前端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Resource
private SslHandlerFactory sslHandlerFactory;

@Override
protected void initChannel(SocketChannel ch) {
//【新增】安插 SSL 保安,必须放在第一位!
ch.pipeline().addLast(sslHandlerFactory.createSslHandler(ch.alloc()));

ch.pipeline().addLast("idleStateHandler", new IdleStateHandler(120, 0, 0));

ch.pipeline().addLast(new MqttDecoder());
ch.pipeline().addLast(MqttEncoder.INSTANCE);

ch.pipeline().addLast(mqttConnectHandler);
ch.pipeline().addLast(mqttPingReqHandler);
ch.pipeline().addLast(new HeartbeatHandler());
}

至此,你的代码就在 TCP 握手之后、MQTT CONNECT 发生之前,强制加了一层物理级的证书校验。如果没有合法的客户端证书,恶意设备连 TCP 握手都完不成,瞬间就会被 SslHandler 掐断。


测试验证

启动服务端。客户端 MQTTX 的设置:

  • 在 SSL/TLS 配置中,勾选 SSL安全。
  • CA File: 上传 ca.crt。
  • Client Certificate File: 上传 client.crt。
  • Client Key File: 上传 client.key (这里可以是原始的 client.key)。
  • 最后注意,由于要进行双向验证(不仅服务端验证客户端,客户端也要验服务端),这里的服务地址必须得写 server 证书签发时候的域名 Koohub!


支持 token 认证校验

在现有的双向 SSL (mTLS) 基础之上加入 Token 验证,是生产环境中最常见的安全组合:TLS 负责传输层身份验证,Token 负责应用层授权与鉴权。MQTT 协议中,userName 和 password 字段最常用于放置身份鉴权信息。如果 Token 较长,建议使用 password 字段传输。

由于 MQTT 是在 CONNECT 包中进行身份验证的,我们需要在 MqttConnectHandler 中从 password 字段提取 Token 并进行校验。MQTT 协议本身不支持在连接过程中动态刷新 Token。如果 Token 过期,通常的做法是服务端在检测到 Token 过期后,主动断开该连接 (ctx.close()),强制客户端使用新 Token 重新发起 CONNECT 请求。

  • 第一层 (TLS 层): 已经通过配置的 ca.crt 保证了只有持有合法证书的客户端才能建立 TCP 连接。
  • 第二层 (Token 层): 在 MQTT 握手阶段通过 CONNECT 包校验用户身份,确保连接到系统的用户是合法的业务用户。

修改 MqttConnectHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
@Slf4j
@Component
@ChannelHandler.Sharable // 让这个 Handler 变成单例,可以被多通道安全共享
public class MqttConnectHandler extends SimpleChannelInboundHandler<MqttMessage> {

@Resource
private ServerConnectionManager connectionManager;
public static final AttributeKey<String> ATTR_CLIENT_ID = AttributeKey.valueOf("clientId");

@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
if (msg.fixedHeader().messageType() == MqttMessageType.CONNECT) {
MqttConnectMessage connectMessage = (MqttConnectMessage) msg;
String clientId = connectMessage.payload().clientIdentifier();
String username = connectMessage.payload().userName();

// 获取 Token (通常放在 password 字段)
byte[] passwordBytes = connectMessage.payload().passwordInBytes();
String token = (passwordBytes != null) ? new String(passwordBytes, StandardCharsets.UTF_8) : null;
log.info(">>>> [MQTT网关] 收到客户端连接请求! ClientId: {}, Username: {}, tokenLen:{}", clientId, username, token==null ? 0 : token.length());
// Token 校验逻辑
if (!isValidToken(token)) {
log.warn(">>>> [MQTT网关] Token 校验失败,拒绝连接! ClientId: {}", clientId);
sendConnAck(ctx, MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD);
ctx.close();
return;
}
// 动态设置 idleStateHandler
int clientKeepAlive = connectMessage.variableHeader().keepAliveTimeSeconds();
if (clientKeepAlive > 0) {
int serverIdleTimeout = (int) (clientKeepAlive * 1.5);
ctx.pipeline().replace("idleStateHandler", "idleStateHandler",
new IdleStateHandler(serverIdleTimeout, 0, 0));
log.info(">>>> [MQTT网关] 设备 {} 携带 KeepAlive = {} 秒,服务端已动态调整读超时判死阈值为 {} 秒",
clientId, clientKeepAlive, serverIdleTimeout);
} else {
log.info(">>>> [MQTT网关] 设备 {} 设置 KeepAlive 为 0 (永不超时),服务端保持默认 120 秒安全兜底", clientId);
}

ctx.channel().attr(ATTR_CLIENT_ID).set(clientId);
connectionManager.addConnection(clientId, ctx.channel());
sendConnAck(ctx, MqttConnectReturnCode.CONNECTION_ACCEPTED);

MqttConnAckMessage connAckMessage = getMqttConnAckMessage();
ctx.writeAndFlush(connAckMessage);
log.info("<<<< [MQTT网关] 已向客户端发送握手成功响应 (CONNACK)");
} else if (msg.fixedHeader().messageType() == MqttMessageType.PUBLISH) {
MqttPublishMessage pubMsg = (MqttPublishMessage) msg;
String topic = pubMsg.variableHeader().topicName();
String content = pubMsg.payload().toString(CharsetUtil.UTF_8); // 读取 payload 内容并打印
log.info(">>>> [MQTT消息接收] 收到新消息!Topic: {}, 内容: {}", topic, content);
ReferenceCountUtil.retain(msg);
ctx.fireChannelRead(msg);
} else {
log.info("收到非CONNECT类型的MQTT消息: {},顺延给下一个Handler处理", msg.fixedHeader().messageType());
ReferenceCountUtil.retain(msg);
ctx.fireChannelRead(msg);
}
}

private boolean isValidToken(String token) {
// TODO 这里编写你的业务逻辑,比如校验 JWT 或者查数据库
return token != null && token.startsWith("owlias"); // 示例:简单判断是否像 JWT
}

private void sendConnAck(ChannelHandlerContext ctx, MqttConnectReturnCode returnCode) {
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0);
MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(returnCode, false);
ctx.writeAndFlush(new MqttConnAckMessage(fixedHeader, variableHeader));
}

@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
String clientId = ctx.channel().attr(ATTR_CLIENT_ID).get(); // 从标签里取出这个人是谁,然后从生死簿里剔除
connectionManager.removeConnection(clientId);
super.channelInactive(ctx);
}

private static MqttConnAckMessage getMqttConnAckMessage() {
MqttFixedHeader fixedHeader = new MqttFixedHeader(
MqttMessageType.CONNACK, // 消息类型:连接确认
false,
MqttQoS.AT_MOST_ONCE,
false,
0
);
MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(
MqttConnectReturnCode.CONNECTION_ACCEPTED, // 状态码:接受连接(成功)
false
);
// 组装成最终的 MQTT 消息
MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader);
return connAckMessage;
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.error("通道发生异常", cause);
ctx.close();
}
}


服务端主动给客户端下发消息

在 Netty 和 MQTT 架构中,实现 “从服务器主动向特定客户端下发消息” 的核心在于利用 ServerConnectionManager 维护的连接池。既然你已经有了一个管理连接的组件,实现思路如下:

  1. 获取 Channel:通过 ServerConnectionManager 根据 clientId 获取该设备的 Channel。
  2. 构建 MQTT PUBLISH 消息:使用 Netty 的 MqttMessageFactory 构建一个 MQTT 协议标准的 PUBLISH 包。
  3. 写入通道:调用 channel.writeAndFlush() 将消息发送出去。

创建一个 MqttPushService 来专门处理推送业务。下面代码使用的是 MqttQoS.AT_MOST_ONCE (QoS 0,即“至多一次”)。如果控制指令非常关键(不能丢失),建议改为 MqttQoS.AT_LEAST_ONCE (QoS 1),并在 Netty 中处理对应的 PUBACK 确认逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Slf4j
@Service
public class MqttPushService {

@Resource
private ServerConnectionManager connectionManager;

/**
* 主动向客户端下发控制指令
* @param clientId 目标设备ID
* @param topic 下发的主题
* @param content 控制指令内容
*/
public void pushCommand(String clientId, String topic, String content) {
// 1. 从连接池获取 Channel
Channel channel = connectionManager.getConnection(clientId);

if (channel == null || !channel.isActive()) {
log.warn(">>>> [推送失败] 设备 {} 不在线,无法下发指令", clientId);
return;
}

// 2. 构建 MQTT PUBLISH 消息
// Fixed Header
MqttFixedHeader fixedHeader = new MqttFixedHeader(
MqttMessageType.PUBLISH, false, MqttQoS.AT_MOST_ONCE, false, 0);

// Variable Header (Topic)
MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, 0);

// Payload (内容)
ByteBuf payload = Unpooled.copiedBuffer(content, StandardCharsets.UTF_8);

MqttPublishMessage message = new MqttPublishMessage(fixedHeader, variableHeader, payload);

// 3. 发送消息
channel.writeAndFlush(message).addListener(future -> {
if (future.isSuccess()) {
log.info(">>>> [推送成功] 已向设备 {} 下发指令: {}", clientId, content);
} else {
log.error(">>>> [推送异常] 向设备 {} 下发指令失败", clientId, future.cause());
}
});
}
}

新建一个测试 controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Protobuf 序列化后只占用大约 14 字节。/**
* MQTT 设备控制控制器
* 用于对外提供 REST 接口触发下发消息
*/
@Slf4j
@RestController
@RequestMapping("/api/mqtt/device")
public class MqttDeviceController {

@Resource
private MqttPushService mqttPushService;

/**
* 发送指令接口
* 请求示例: POST /api/mqtt/device/push
* {
* "clientId": "device_001",
* "topic": "/control/reboot",
* "content": "{\"cmd\": \"reboot\"}"
* }
*/
@PostMapping("/push")
public ResponseEntity<String> pushCommand(@RequestBody PushRequest request) {
log.info(">>>> [API接口] 收到下发请求: clientId={}, topic={}", request.getClientId(), request.getTopic());

try {
mqttPushService.pushCommand(request.getClientId(), request.getTopic(), request.getContent());
return ResponseEntity.ok("指令下发请求已受理");
} catch (Exception e) {
log.error(">>>> [API接口] 下发指令异常", e);
return ResponseEntity.internalServerError().body("下发失败: " + e.getMessage());
}
}

@Data
public static class PushRequest {
private String clientId;
private String topic;
private String content;
}
}


使用 protobuf 下发消息

为什么要用 protobuf

在物联网(IoT)和高并发网关的场景下,把原有的 JSON/纯文本改为 Protobuf 下发消息,核心原因可以总结为四个字:小、快、省、严

  • 比如同样是下发一条指令:{“cmd_id”:”1001”,”action”:”reboot”},JSON 占用约 37 字节,而 Protobuf 序列化后只占用大约 14 字节。数据量缩减了 60% 以上。在百万级设备在线的网关中,这能瞬间挤出大量的带宽,直接降低运营商网络带宽成本。
  • 机器去解析 JSON 文本时,需要进行大量的字符串匹配、词法分析和内存分配。频繁的 JSON 文本解析会疯狂吃 CPU,并引发 JVM 的频繁垃圾回收(GC)。而 Protobuf 是直接在二进制位(Bit/Byte)上进行按位读取,几乎是底层内存的高速复制,完全不需要进行字符串复杂的比对和转换。其反序列化速度比 JSON 快 5 到 10 倍,大幅释放了服务端的 CPU 压力。


具体做法

新增protobuf模块

依赖配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.owlias</groupId>
<artifactId>owlias-mqtt</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>mqtt-protobuf</artifactId>

<dependencies>
<!-- Protobuf 核心库 -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java</artifactId>
<version>3.5.1</version>
</dependency>
<!-- JSON 与 Protobuf 互转工具 -->
<dependency>
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-core</artifactId>
<version>1.5.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty</artifactId>
<version>1.5.0</version>
<scope>compile</scope>
<exclusions>
<exclusion>
<artifactId>netty-codec-http2</artifactId>
<groupId>io.netty</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<extensions>
<extension>
<groupId>kr.motd.maven</groupId>
<artifactId>os-maven-plugin</artifactId>
<version>1.6.0</version>
</extension>
</extensions>
<plugins>
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.5.0</version>
<configuration>
<protocArtifact>
com.google.protobuf:protoc:3.1.0:exe:${os.detected.classifier}
</protocArtifact>
<pluginId>grpc-java</pluginId>
<pluginArtifact>
io.grpc:protoc-gen-grpc-java:1.11.0:exe:${os.detected.classifier}
</pluginArtifact>
</configuration>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>compile-custom</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

定义 Protobuf 文件 (src/main/proto/MyCommand.proto)。在项目中创建该文件。Maven 插件会自动读取该目录下的文件并生成代码。

1
2
3
4
5
6
7
8
9
10
11
12
syntax = "proto3";

package com.owlias.mqtt.proto;
option java_package = "com.owlias.mqtt.proto";
option java_outer_classname = "MyCommand";

message DeviceCommand {
string cmdId = 1;
string action = 2;
int32 priority = 3;
map<string, string> params = 4;
}

编译与生成:在项目根目录运行 mvn compile,插件会自动在 target/generated-sources/protobuf/java 下生成 CommandProto.java 文件。记得在 IDE 中将生成的目录标记为 “Generated Sources Root”,这样你就可以在代码中直接导入 com.owlias.mqtt.proto.MyCommand 了。


使用 protobuf

在 mqtt-server 模块中引入依赖:

1
2
3
4
5
6
<!-- 依赖本地的 protobuf 模块 -->
<dependency>
<groupId>com.owlias</groupId>
<artifactId>mqtt-protobuf</artifactId>
<version>${project.version}</version>
</dependency>

新增 controller 测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Slf4j
@RestController
@RequestMapping("/api/mqtt/device")
public class MqttDeviceController {
@Resource
private MqttPushService mqttPushService;

@PostMapping("/push2")
public ResponseEntity<String> pushCommand2() {
try {
// 构造具体的 Protobuf 消息体
MyCommand.DeviceCommand cmd = MyCommand.DeviceCommand.newBuilder()
.setCmdId("1001")
.setAction("reboot")
.setPriority(121)
.putParams("delay", "5")
.putParams("expire", "10")
.build();
mqttPushService.pushCommand("mqttx_0d913469", "topic012", cmd);
return ResponseEntity.ok("指令下发请求已受理");
} catch (Exception e) {
log.error(">>>> [API接口] 下发指令异常", e);
return ResponseEntity.internalServerError().body("下发失败: " + e.getMessage());
}
}
}

对应的 service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Slf4j
@Service
public class MqttPushService {

@Resource
private ServerConnectionManager connectionManager;

/**
* 新增:下发 Protobuf 格式的二进制消息
* @param clientId 目标设备ID
* @param topic 主题
* @param message Protobuf 生成的 Message 对象
*/
public void pushCommand(String clientId, String topic, Message message) {
Channel channel = connectionManager.getConnection(clientId);

if (channel == null || !channel.isActive()) {
log.warn(">>>> [Protobuf推送] 设备 {} 不在线", clientId);
return;
}

// 1. 将 Protobuf 对象序列化为字节数组
byte[] protoBytes = message.toByteArray();

// 2. 构建 MQTT PUBLISH 消息
MqttFixedHeader fixedHeader = new MqttFixedHeader(
MqttMessageType.PUBLISH, false, MqttQoS.AT_MOST_ONCE, false, 0);
MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, 0);

// 3. 将字节数组放入 Netty 的 ByteBuf
ByteBuf payload = Unpooled.wrappedBuffer(protoBytes);
MqttPublishMessage mqttMsg = new MqttPublishMessage(fixedHeader, variableHeader, payload);
channel.writeAndFlush(mqttMsg).addListener(future -> {
if (future.isSuccess()) {
log.info(">>>> [Protobuf推送] 成功发送指令到 {}", clientId);
}
});
}
}